security: cache derived SCRAM stored key #31054
Conversation
Measures the per-call cost of the SCRAM password check that HTTP Basic auth runs on every pandaproxy/schema registry/admin request. The salted password is rederived per request (4096 HMAC rounds), which saturated pandaproxy CPU under unbatched HTTP produce in a production incident: ~473us per SHA-256 validation, so a few thousand requests per second exhaust a core before any request work happens. Covers the wrong password (reject) path, which costs the same as an accept, SHA-512 (~3x SHA-256), and a unique-password case that always pays the full derivation.
Exposes the password -> stored_key derivation as a reusable step so a subsequent commit can memoize it.
There was a problem hiding this comment.
Pull request overview
This PR introduces a per-shard memoization layer for SCRAM password validation to eliminate repeated PBKDF2-style derivations on hot authentication paths (HTTP Basic auth and SASL/PLAIN), controlled via a new cluster configuration kill switch.
Changes:
- Add
security::scram_credential_cacheand integrate it intovalidate_scram_credentialwith athread_localper-shard cache gated byscram_credential_cache_enabled. - Refactor SCRAM algorithm code to expose
derive_stored_key()and reuse it in password validation. - Add unit tests and an rpbench benchmark covering cached vs uncached behavior and correctness.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/v/security/tests/scram_credential_cache_test.cc | New unit tests validating cache hit/miss behavior, key composition, eviction bounds, and cached credential validation. |
| src/v/security/tests/scram_credential_bench.cc | New perf benchmark exercising repeated/unique password validation cases to quantify caching impact. |
| src/v/security/tests/scram_algorithm_test.cc | Adds a test ensuring derive_stored_key() matches stored keys produced by credential creation. |
| src/v/security/tests/BUILD | Wires new test and benchmark targets into the build. |
| src/v/security/scram_credential_cache.h | Declares the per-shard SCRAM derivation cache and its keying/metrics interface. |
| src/v/security/scram_credential_cache.cc | Implements HMAC-keyed cache entries backed by utils::chunked_kv_cache. |
| src/v/security/scram_authenticator.h | Documents cached validation behavior and introduces a detail::validate_scram_credential overload for caller-provided caches. |
| src/v/security/scram_authenticator.cc | Implements cached derivation path and config-gated use of a per-thread cache. |
| src/v/security/scram_algorithm.h | Adds derive_stored_key() and refactors validate_password() to use it. |
| src/v/security/BUILD | Adds the new cache implementation to the security library and links required deps. |
| src/v/config/configuration.h | Declares the new scram_credential_cache_enabled configuration property. |
| src/v/config/configuration.cc | Defines the new configuration property and its description/default. |
|
Force push to address Copilot comments. |
CI test resultstest results on build#86904
test results on build#86936test results on build#86985
|
|
/microbench |
|
Performance change detected in https://buildkite.com/redpanda/redpanda/builds/86918#019f4614-84b6-4266-9e37-2288a79527c5: See https://redpandadata.atlassian.net/wiki/x/LQAqLg for docs |
|
Force push to change default behavior to disabled. |
Retry command for Build#86936please wait until all jobs are finished before running the slash command |
dotnwat
left a comment
There was a problem hiding this comment.
good stuff. just some questions
| /// changes the cache key. | ||
| class scram_credential_cache { | ||
| public: | ||
| static constexpr size_t default_capacity = 1024; |
There was a problem hiding this comment.
should be a configuration option?
There was a problem hiding this comment.
Could be, but not sure if it'd be useful or just a config option that's ignored and never gets changed. 1024 seems like a reasonable default, something like ~256 KB per shard. But happy to add it if you think we should.
| stats_t stats() const; | ||
|
|
||
| private: | ||
| static constexpr size_t key_digest_size = 32; |
There was a problem hiding this comment.
is this a duplicate of the one at the top of the file?
There was a problem hiding this comment.
Good catch, folded them together.
| const credential_password& password, | ||
| bytes_view salt, | ||
| int iterations) const { | ||
| hmac_sha256 mac(_digest_key); |
There was a problem hiding this comment.
can you explain why we use hmac to build the key (for example as opposed to feeding all the parameters and the secure digest through sha256)?
There was a problem hiding this comment.
So from what I read, directly hashing sha256(secret ‖ params) has a length-extension weakness and HMAC is supposed to be the standard way to key a hash with a secret. And the secret is there so the keys aren't plain password hashes.
| auto valid = stored_key == cred.stored_key(); | ||
| if (cache != nullptr) { | ||
| cache->put( | ||
| mech, |
There was a problem hiding this comment.
if mech is not used to compute the stored key, why is it part of the cache key?
There was a problem hiding this comment.
Right, the mech value itself isn't used in the computation, the scram template parameter is, but they're supposed to be related. I'll update it to derive mech from the template instead of passing it as well.
|
|
||
| namespace detail { | ||
|
|
||
| std::optional<std::string_view> validate_scram_credential( |
There was a problem hiding this comment.
should we restrict to the rest proxy authentication? kafka, admin, etc... use scram but likely don't have any performance concerns (e.g. kafka is per connection auth).
There was a problem hiding this comment.
Looks like the only callers to this validate_scram_credential (and users of this cache) are the HTTP Basic surfaces (pandaproxy, schema registry, admin) that re-auth on every request plus Kafka SASL/PLAIN (but only per-connection). Kafka SASL/SCRAM goes down a different path so it doesn't use it. Since they all flow into here, I think it's easier/cleaner to leave it than to pull it out/restrict it to just panda proxy, but that's really my only reason - we can restrict it if you think there's a good reason to.
Per-shard memoization of SCRAM password derivations, keyed by an HMAC (random per-instance key) of (mechanism, password, salt, iterations) so plaintext passwords are not retained, holding only the derived stored key: the same datum already persisted in the credential store. The mapping is purely functional, so entries never go stale: updating a credential generates a fresh salt, which changes the cache key. Built on the S3-FIFO chunked_kv_cache, whose scan resistance keeps one-shot wrong-password entries from displacing hot legitimate ones.
validate_scram_credential now consults a shard-local scram_credential_cache, skipping the salted password derivation for repeat authentications. HTTP Basic auth (pandaproxy, schema registry, admin API) and SASL/PLAIN pay that derivation on every request; in production, unbatched HTTP produce through pandaproxy at a few hundred requests per second saturated a core on it. Cache hits skip the derivation entirely; the uncached path is unchanged. The scram_credential_cache_enabled cluster config (default true) is an operational kill switch.
a81d011 to
cb63f4f
Compare
|
Force push to address PR comments. |
HTTP Basic auth (PandaProxy, Schema Registry, Admin API) and Kafka SASL/PLAIN
rederive the credential's stored SCRAM key on every request: a PBKDF2-style
loop of >=4096 HMAC rounds, run on-reactor before the body is parsed. Under
high-rate authenticated traffic this dominates CPU. A cluster serving a few
hundred HTTP-produce req/s per shard through PandaProxy saturated its reactor
core almost entirely in this path (~45% of on-CPU time in
validate_scram_credential), starving heartbeats.This memoizes the derivation with a bounded per-shard cache, keyed by an HMAC
of (algorithm, password, salt, iterations) and holding only the derived stored
key (already in the credential store). It is purely functional, so a password
change re-salts and re-keys and entries never go stale.
The cache is gated by the
scram_credential_cache_enabledcluster config andships disabled by default. Because it caches the result of password
validation in memory, enabling it by default is deferred pending security team
review; until then it is strictly opt-in and out-of-the-box behavior is
unchanged.
Fixes CORE-16829
Security considerations (for review)
credential store; no plaintext or salted password is retained.
random per-boot key, so a memory dump exposes no offline-crackable material
and keys can't be correlated across shards or reboots.
stored key, so a rotated credential (re-salted, hence re-keyed) or a
cache-key collision fails closed. A false accept would require colliding both
the HMAC key and the SCRAM stored key at once — two independent SHA
collisions, one under a secret key — negligible and no weaker than SCRAM's
existing assumptions.
correct and wrong passwords are cached, so a hit only reveals that an exact
(user, password) was tried recently — not whether it is correct. Correctness
still comes from the 200/401 response, and distinct guesses (a dictionary
attack) always miss. Caching failed validations also keeps bad-credential
floods cheap.
Microbench (
--config=release, single core)Repeat = same credential per call (steady-state auth); unique = fresh password
per call (cache-miss path).
End-to-end (single core, unbatched ~2 KiB HTTP produce)
The cache is disabled by default, so out-of-the-box throughput is the Disabled
column. Enabling it (
scram_credential_cache_enabled=true) raises Basic-auththroughput to match the no-auth baseline; toggling it back off returns to the
Disabled numbers, so the config gates the behavior cleanly in both directions.
Backports Required
Release Notes